9 Combining Animations¶
Most of this book’s animations deal with user interaction. In earlier chapters, you used animation to draw the user’s attention to the desired area in your app. These animations help guide the user while at the same time adding polish and improving the app’s visual appearance.
In this chapter, you’ll build an animation to act as a reward for the user when the steeping timer ends. This animation will show liquid pouring into the view’s background and filling it up.
Since this is a more complex animation, you’ll build it in two parts. First, you’ll add the animation that resembles a rising liquid within a container. You’ll then use SpriteKit’s particle system to add the pouring liquid that appears to fill the container.
Building a Background Animation¶
Open the starter project for this chapter. You’ll see the familiar Tea Brewing from previous chapters.
The start project contains a new group called PourAnimation, which includes the TimerComplete
view shown when a steeping timer finishes. To start the new animation, create a SwiftUI view file named PourAnimationView.swift in the PourAnimation folder`.
You’ll use this view to contain the new animation’s views. As with other animations, starting with a simple version and then expanding upon it to create the final animation is the easiest. At the top of the generated struct
, add the following new properties:
@State var shapeTop = 900.0
let fillColor = Color(red: 0.180, green: 0.533, blue: 0.78)
This code adds a state property you’ll use to control the animation. You also define a blue color you’ll use as the liquid’s color. Update the body of the view to:
// 1
Rectangle()
// 2
.fill(fillColor)
// 3
.offset(y: shapeTop)
// 4
.onAppear {
withAnimation(.linear(duration: 6.0)) {
shapeTop = 0.0
}
}
Here’s what the code does:
- You define a
Rectangle
shape that you’ll replace with a more complexShape
later. - You fill the
Rectangle
with the blue color you defined earlier. - This offsets the rectangle by the amount of
shapeTop
. By changingshapeTop
, you can change the position of the top of the rectangle on the view. - When the view appears, you use an explicit linear animation that takes six seconds to complete. SwiftUI will apply the animation when you change
shapeTop
to zero. The animation will then animate the movement of theRectangle
from the initial position to the top of the view.
You need to add this new view to the view that shows when the timer finishes. Open TimerComplete.swift. This view consists of a ZStack
, which starts with a backgroundGradient
. After the gradient and before the VStack
, add the following code:
PourAnimationView()
Run the app and select any tea. Start the timer and wait for it to complete. Once the timer finishes, you’ll see the animation as the blue rectangle fills the view over six seconds, like a cup filling with liquid. Remember, you can adjust the timer length.
The clipped area at the bottom seems out of place. By default, SwiftUI keeps a view from entering the device’s safe area. To eliminate the bar at the bottom, you need to tell SwiftUI to allow the view to extend into that area.
In TimerComplete.swift, change the call to the view to:
PourAnimationView()
.ignoresSafeArea(edges: [.bottom])
ignoresSafeArea(_:edges:)
tells SwiftUI to allow the view to extend into part of that bottom part of the safe area.
Run the app, start a timer and let it complete. The Rectangle
’s fill color now extends to the bottom of the screen.
Now that you’ve built the basics of the pouring animation, you’ll make the top of the rising liquid more realistic in the next section.
Making a Wave Animation¶
If you watch a liquid pouting into a cup, you’ll see the top of the liquid is anything but a smooth, flat surface. It makes a much more chaotic and complex flow.
While implementing actual fluid dynamics would be overkill, you can simulate a more complex shape to the pour using a sine wave. In this section, you’ll implement a custom Shape
and change the top of the animation to a sine wave.
In the PourAnimation folder, create a new SwiftUI view file named WaveShape.swift. You’ll create a custom shape instead of a view, so replace the existing generated struct
with:
struct WaveShape: Shape {
func path(in rect: CGRect) -> Path {
Path()
}
}
A Shape
returns a Path
that defines the shape instead of a View
. SwiftUI passes a CGRect
struct as a parameter to the method. It contains the size of the container for the shape. This initial implementation only returns an empty path, but not for long.
To see your shape in the preview as you develop it, change the preview to:
WaveShape()
.stroke(.black)
.offset(y: 200)
This change strokes the path in black in the preview. It also uses a vertical offset, so the full path shows on the preview. Otherwise, you’d cut off the top portion when SwiftUI draws it on the view.
In previous chapters, you used sine and other trigonometric functions in animations when drawing lines at an angle. Here, you’ll use it since the top of your shape will be a sine wave.
The plot of the sine function from zero through 360 degrees looks like this:
It produces a perfect wave shape with the vertical axis ranging between negative one and one over the distance. Due to the definition of a sine function, the wave varies regularly over the 360 degrees that make up a single revolution of a circle. After 360 degrees, the values repeat with y
taking on the same value it did at the same angle minus 360 degrees.
To implement this shape in SwiftUI, replace the closure of path(in:)
with:
// 1
Path { path in
// 2
for x in 0 ..< Int(rect.width) {
// 3
let angle = Double(x) / rect.width * 360
// 4
let y = sin(Angle(degrees: angle).radians) * 100
// 5
if x == 0 {
path.move(to: .init(x: Double(x), y: -y))
} else {
path.addLine(to: .init(x: Double(x), y: -y))
}
}
}
Here’s how this code draws the sine wave as a shape:
- You create an an empty Path and accept a
path
to manipulate in the trailing closure’s body. - You iterate all
x
positions in the rectangle using afor-in
loop. This loop ensures you perform only the necessary calculations for the shape’s size. - For each
x
position, you calculate the angle it should reflect by dividing it by the total width of the rectangle. This result gives you the position as a fraction of the full width. You then multiply this fraction by 360, giving you the position as a degree of a full 360-degree circle. - You get the sine of the angle from step three using the
sin
method. You convert from degrees to radians inside the function, as with other Swift trigonometric functions. Since this will provide a value between negative one and one, you multiply it by 100, increasing the wave’s size. - The first time through the loop, you move the path location to the current horizontal position and the vertical position calculated in the last step. After that, you draw a line from the current position to the following path position. Since increasing values of
y
on aPath
are downward on the view, you take the negative ofy
to flip positive values upward.
The preview for the shape shows you a simple sine wave:
To make this work in your animation, it must produce a completely closed shape like the Rectangle
. You also need to let the calling view specify a position for the top of the shape. You’ll do that in the next section.
Animating the Sine Wave¶
Add the following new property to the top of the Shape
before path(in:)
:
var waveTop: Double = 0.0
This property lets the calling view control the location of the sine wave. Update the code under comment five to:
// 5
if x == 0 {
path.move(to: .init(
x: Double(x),
y: waveTop - y
))
} else {
path.addLine(to: .init(
x: Double(x),
y: waveTop - y
))
}
This change adds the value of waveTop
to the vertical position of the view. A positive value shifts the wave’s position down the shape.
To close the shape, add the following code after the for-in
loop:
path.addLine(to: .init(x: rect.width, y: rect.height))
path.addLine(to: .init(x: 0, y: rect.height))
path.closeSubpath()
for-in
ends with the position on the right edge of the view. So you add a line to the bottom-right of the view before adding a line to the left-bottom side of the view. You then call closeSubpath()
on the path to ensure it forms a closed shape.
To better see the difference, change the preview to:
WaveShape(waveTop: 200.0)
.fill(.black)
The shape fills in from the view’s bottom up to a point specified by waveTop
. You no longer need offset(x:y:)
on the shape because you can control the location with waveTop
.
Go to PourAnimationView.swift and change the body to use the new shape you just implemented:
WaveShape(waveTop: shapeTop)
.fill(fillColor)
.onAppear {
withAnimation(.linear(duration: 6.0)) {
shapeTop = 0.0
}
}
You’ll see your new shape in the preview, but it immediately jumps to the new position without the animation. To confirm this, run the app and let a timer finish.
The Shape
protocol supports animation but requires you to conform to the Animatable
protocol. Shape
already conforms to Animatable
, so all you have to do is implement its requirements.
Go back to WaveShape.swift and add the following computed property after waveTop
:
var animatableData: Double {
get { waveTop }
set { waveTop = newValue }
}
Animatable
has one requirement, the animatableData
property. This property provides a bridge SwiftUI understands when implementing custom animation for a shape or view.
Run the app and let a timer complete to see that the wave moves smoothly. See Chapter 6: Introduction to Custom Animations for more about the protocol.
This wave shape works, but it’s limited. It only produces a single shape that always looks the same. The current wave height also looks too big for the view. In the next section, you’ll let the calling view modify the wave’s shape.
Modifying the Filling View¶
You can change the shape of a sine wave by changing three properties: amplitude, wave length and phase.
Add the following new properties to WaveShape
after waveTop
:
var amplitude = 100.0
var wavelength = 1.0
var phase = 0.0
The amplitude
determines the height of the wave. By default, the sine function’s values vary between negative one and one. You can multiply that value by another number to change the shape’s height. You already modified this in the initial shape using a fixed value of 100.00
.
To implement the amplitude, change the code under comment four to:
// 4
let y = sin(Angle(degrees: angle).radians) * amplitude
This change replaces the constant 100.0
value with the new property allowing any height wave.
Right now, the shape creates a single wave filling the entire space. The wavelength
property lets you compress or stretch the wave.
To implement the wavelength, change the code under comment three to:
// 3
let angle = Double(x) / rect.width * wavelength * 360.0
The calculation adds a multiplication by the new wavelength
parameter to the previous calculation. If this parameter is greater than one, it’ll increase the number of waves appearing on the screen since the angle will rise more quickly. Think of the parameter as defining how many complete waves will show across the view.
To shift the wave horizontally, change the starting degree. Right now, you begin the wave at zero degrees, which produces a y
of zero. The phase
parameter lets you shift this beginning point so the wave can start at an arbitrary point.
You must adjust the angle calculated in step three to implement the phase parameter. Change the code to:
// 3
let angle = Double(x) / rect.width * wavelength * 360.0 + phase
You calculate an angle in step three and can change this angle by adding the desired change in degrees. The phase
property provides the angle where the drawn wave should begin.
These new properties help you control the parameters of the wave. Open PourAnimationView.swift and change the call to WaveShape()
to:
WaveShape(
waveTop: shapeTop,
amplitude: 15,
wavelength: 4,
phase: 90
)
Run the app and let a tea timer complete. You’ll see your new animation. The wave shows more peaks and troughs with a smaller height and shifted to the right compared to before.
This new wave produces a more realistic fill than a flat surface, but it’s still too static. In the next section, you’ll add some motion to the wave itself.
Animating Multiple Parts of the Wave¶
When you added waveTop
to WaveShape
, you needed to implement animatableData
so SwiftUI could animate it. Therefore, you might expect to do the same for the three additional properties before you can animate them.
However, you have four properties to animate and only one property in the AnimatableData
protocol. To handle these situations, SwiftUI provides the AnimatablePair
struct. It lets you specify a pair of values for the animatableData
property. In addition, each of the two values in the struct can be animatable, meaning you can nest values to support the number of properties you need.
Open WaveShape.swift and replace the animatableData
property with:
// 1
var animatableData: AnimatablePair<
AnimatablePair<Double, Double>,
AnimatablePair<Double, Double>
> {
get {
// 2
AnimatablePair(
AnimatablePair(waveTop, amplitude),
AnimatablePair(wavelength, phase)
)
}
set {
// 3
waveTop = newValue.first.first
amplitude = newValue.first.second
wavelength = newValue.second.first
phase = newValue.second.second
}
}
Here’s how this code implements AnimatableData
for your shape:
- You define the
animatableData
property to have a type ofAnimatablePair<AnimatablePair<Double, Double>,AnimatablePair<Double, Double>>
. To animate fourDouble
s, you need four values. To get those, you need twoAnimatablePair
structs that you wrap inside an externalAnimatablePair
. This struct produces anAnimatablePair
whose first and second values areAnimatablePair
structs whose values are both aDouble
. - When SwiftUI requests the value for the property, you build an
AnimatablePair
struct. The first value of the struct is anAnimatablePair
containing thewaveLength
andamplitude
properties in theShape
. The secondAnimatablePair
struct consists of thewavelength
andphase
properties from theShape
. - When SwiftUI provides new values, you set the properties in the same order as you send them in step two. Notice the use of
newValue.first
to access the elements wrapped in the firstAnimatablePair
andnewValue.second
to access the second pair.
This diagram shows how the properties map through the AnimatablePair
type of animatableData
.
For more on AnimatablePair
, see Chapter 7: Complex Custom Animations.
With this change, you can animate all properties of the WaveShape
. To put this to use, open PourAnimationView.swift and add a new computed property to the top of the view:
var waveHeight: Double {
min(shapeTop / 10.0, 20.0)
}
This property calculates a wave height equal to the top of the shape divided by ten. The value of waveHeight
starts at 20 and decreases as shapeTop
decreases. min
caps the value at 20, so the height isn’t too large at the beginning of the animation.
Update amplitude
in WaveShape
to:
amplitude: waveHeight,
Using the new computed property for the shape’s amplitude
produces a larger wave that decreases as the animation nears the end. Run the app and let a timer complete to see the wave’s height decrease.
Since pouring a liquid produces a chaotic movement, you can make the animation more realistic by adding more movement to the wave. Shifting the phase
for the WaveShape
will do just that.
Open PourAnimationView.swift and add the following new property after shapeTop
:
@State var wavePhase = 90.0
Change phase
to the WaveShape
view to read:
phase: wavePhase
This parameter has the shape use the new state property. Add the following code at the start of onAppear(perform:)
:
withAnimation(
.easeInOut(duration: 0.5)
.repeatForever()
) {
wavePhase = -90.0
}
You do the same for the phase as you did when you changed shapeTop
to animate a rising shape. Changing the phase adds a back-and-forth movement to the water in the view as it rises. You create an ease-in-out animation lasting one-half second. repeatForever(autoreverses:)
tells SwiftUI to repeat the animation forever. Since autoreverses
defaults to true
, the animation will reverse before repeating.
Run the app and let a tea timer complete. You’ll see the new motion in the animation.
Now that your animation resembles water rising in a cup, you’ll add another wave in the next section to give the animation more complexity.
Adding Multiple Waves¶
While your wave resembles rising water, you can enhance the effect by adding more waves offset from the current wave.
Open PourAnimationView.swift and add the following new property after wavePhase
:
@State var wavePhase2 = 0.0
Also, add a new color definition after fillColor
:
let waveColor2 = Color(red: 0.129, green: 0.345, blue: 0.659)
Wrap the current WaveShape
inside a ZStack
by Command-clicking WaveShape
and selecting Embed in ZStack from the menu. Keep .fill(fillColor)
with WaveShape
and move onAppear(perform:)
to the ZStack
. Add the following code inside the new ZStack
and before the existing WaveShape
:
WaveShape(
waveTop: shapeTop,
amplitude: waveHeight * 1.2,
wavelength: 5,
phase: wavePhase2
)
.fill(waveColor2)
This code produces a wave shape based on the existing one. It’s 1.2 times higher and shows five complete waves across the view. You also use the newly added wavePhase2
as the phase.
To animate this property of the new shape, add the following code to the onAppear(perform:)
after the withAnimation(_:_:)
that changes wavePhase
:
withAnimation(
.easeInOut(duration: 0.3)
.repeatForever()
) {
wavePhase2 = 270.0
}
Run the app, and you’ll see a second, darker blue wave behind the existing one. It appears behind the first since you placed it first in the ZStack
.
Now that you have a nice animation of the view filling, the only thing missing is what’s filling it. You’ll start adding the pour in the next section.
Animation With Particles¶
The most efficient way to create a pour animation, the animation of a liquid acting under gravity, is to use a particle system. A particle system is a group of points that change under rules that affect their behavior and appearance. They work well to create effects such as smoke, rain, confetti and fireworks.
It’s possible to write one natively in SwiftUI, but there’s no need in this case since Apple provides particle systems in several libraries. In this section, you’ll begin implementing a particle system in SceneKit and SpriteKit to add to your animation. SwiftUI supports SceneKit through the SceneView
view, displaying SceneKit content.
To create the pour animation, you must build up several elements and combine them into a SceneKit scene. You’ll start with the particle emitter.
Creating a Particle Emitter¶
Under the PourAnimation folder, create a new SpriteKit Particle File. For Particle template, select Rain and click Next. Name it PourParticle. The preview will show the new particle file, which resembles a light rain:
Select the Attributes Inspector for the particle file and change the following values:
- Change Texture to
dropshape
to select a drop shaped image for the particle. - Change Emitter ▸ Birthrate to
600
to increase the number of particles. - Change Position Range ▸ X to
55
as a lower number reduces the size of the space where the emitter creates particles. - Change Angle ▸ Start to
270
to produce particles with a veritcal downward motion. - Change Speed ▸ Start to
600
to speed up the particle motion.
Your final particle will look like this:
Click the circle next to Color Ramp. This selection will bring up a color picker. Select the second tab, which shows a slider option. Change the slider to RGB Sliders and change the Hex Value field in the bottom right to #1898FF
.
The particles take on a blue color that may be hard to see on the default black background. You can change the Custom color to white to help them stand out.
With your completed particle emitter, you can create a SceneKit scene to hold the emitter. You’ll begin that in the next section.
Building a SceneKit Scene¶
First, you need a SwiftUI view that’ll display your SceneKit scene. Inside the PourAnimation folder, create a new SwiftUI view file named PourSceneView. At the top of the new file, add a second import:
import SpriteKit
You import SpriteKit because it includes both SpriteKit and SceneKit, which you’ll use in this view.
First, you create a SKScene
that defines the scene. At the top of the file before PourSceneView
, add:
class PouringLiquidScene: SKScene {
static let shared = PouringLiquidScene()
}
This bare-bones implementation contains only a single static property that creates an instance of itself. You’ll use this class to define the view-independent properties for the scene. Add the following property to the class after the static property:
let dropEmitter = SKEmitterNode(fileNamed: "PourParticle")
SKEmitterNode
loads the particle emitter you created in the last section. Notice you don’t need to specify the file’s extension,
You set up a SKScene
inside didMove(to:)
. The framework calls the method when the scene is presented to the view. Add the following code to your class:
override func didMove(to view: SKView) {
// 1
self.backgroundColor = .clear
// 2
if let dropEmitter,
!self.children.contains(dropEmitter){
self.addChild(dropEmitter)
}
// 3
dropEmitter?.position.x = 100
dropEmitter?.position.y = self.frame.maxY
}
Here’s the setup for SKScene
:
- You set the scene’s background color to
clear
. This change lets anything behind the scene, like other views, show through. - You attempt to unwrap
dropEmitter
. If successful, you then ensure the emitter isn’t already present in the scene before adding it as a child of the current scene. UnwrappingdropEmitter
can only fail if PourParticle.sks (the particle file you created) is missing or corrupt. - Particles from a
SKEmitterNode
appear at the location you provide to theposition
property. You set the horizontal position 100 points from the left edge. Unlike most SwiftUI-related coordinates, in aSKScene
, they
value increases going upward in the view. Therefore, you set they
position toself.frame.maxY
, placing it at the top of the view.
With that class in place, you can now use it in your SwiftUI view. Add the following computed property to PourSceneView
:
var pouringScene: SKScene {
// 1
let scene = PouringLiquidScene.shared
// 2
scene.size = UIScreen.main.bounds.size
scene.scaleMode = .fill
// 3
return scene
}
This property produces the SKScene
you’ll use inside your SwiftUI view by:
- This gets the shared instance of the class through the
shared
property. - You set the size to match the size of the main screen, so the
SKScene
takes up the full view. You also set the scale mode to.fill
to fill the entire view. - You return this modified view.
Finally, change the body of the view to:
SpriteView(
scene: pouringScene,
options: [.allowsTransparency]
)
You call SpriteView
, passing in the scene’s name from your pouringScene
computed property. You pass .allowsTransparency
to the options argument. Otherwise, the views below this in the stack wouldn’t show through SpriteView
and your self.backgroundColor = .clear
setting in didMove(to:)
would be ignored.
Check out the preview, where you can see the pouring liquid you created:
With the SceneKit view added to a SwiftUI view, you can quickly finish the animation in the next section by combining the two.
Finishing the Animation¶
Open PourAnimationView.swift and add the following state property after wavePhase2
:
@State var showPour = true
This property controls showing the pouring animation. Add the following code before the first WaveShape()
inside the ZStack
:
if showPour {
PourSceneView()
}
Run the app, select any tea and let the timer complete. You’ll see the new particle animation added to the view.
Since you added it before the WaveShape
views, the pouring particle appears behind the rising liquid. However, the rising animation begins before the particles reach the bottom of the view, which spoils the illusion that the pouring causes the liquid to rise. To fix this, you can add a short delay before the liquid begins to rise. Inside onAppear(perform:)
, find the last withAnimation(_:_:)
, which changes shapeTop
, and change it to:
withAnimation(
.linear(duration: 6.0)
.delay(1)
) {
shapeTop = 0.0
}
You use the delay(_:)
modifier on the linear animation with a value of 1 which delays for one second before changing shapeTop
to zero and beginning the rising liquid animation.
For performance reasons, you don’t want the particle emitter to keep running once the animation completes, which occurs when shapeTop
reaches zero. If you directly compared shapeTop
to zero, the explicit animation on shapeTop
would cause SwiftUI to apply a transition to the view removal, fading it away. Instead, add the following code to the end of onAppear(perform:)
:
DispatchQueue.main.asyncAfter(deadline: .now() + 7.0) {
showPour = false
}
This code sets showPour
to false after seven seconds, hiding the view. You get seven seconds from the one-second delay above plus the six seconds length of the animation. Run the app and let a tea timer complete to see your finished animation.
Key Points¶
- You can use animations to draw the user’s attention to an element and add a nice visual to reinforce the user’s action.
- You can combine multiple animations to produce a finished visual effect for complex animations.
- The SwiftUI animation system is robust and capable, but you can leverage other Apple frameworks when creating animations. SwiftUI lets you efficiently use them in your SwiftUI project.
- SceneKit includes a particle system that works well to produce smoke, rain, confetti and fire.
Where to Go From Here?¶
Chapter 6: Introduction to Custom Animations and Chapter 7: Complex Custom Animations of this book go into more detail on using the AnimatableData
and AnimatablePair
protocols.
For more about SceneKit, see SceneKit 3D Programming for iOS: Getting Started.
You can read more about the SceneKit particle system in SceneKit Tutorial with Swift Part 5: Particle Systems.